Subsystem 1b-iii: server removal & account disconnect (cascade, failover, UI) - #7
Conversation
Adds FK_ConnectionStates_RustServers_RustServerId with Cascade delete so removing a RustServer automatically removes its orphaned ConnectionState row. Updates three existing tests to seed a real RustServer before inserting ConnectionState (required once FK enforcement applies), and adds a new test that verifies the cascade behaviour end-to-end. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ify) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Completes Subsystem 1b-iii by adding destructive lifecycle flows (server removal + account disconnect), introducing a credential-change event consumed by Connections/Workspace for failover + UI refresh, and enforcing DB cascade to prevent orphaned connection-state rows.
Changes:
- Add server removal orchestration (stop socket → delete server row → tear down workspace) and UI button wiring.
- Add account disconnect flow (stop listener → disable registration → remove owner creds → publish per-server credential-change events) plus UI button/confirmations.
- Add ConnectionState → RustServer FK with cascade delete + related tests/migrations.
Reviewed changes
Copilot reviewed 40 out of 41 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/RustPlusBot.Persistence.Tests/Credentials/CredentialStoreTests.cs | Adds coverage for removing/listing credentials per owner across servers. |
| tests/RustPlusBot.Persistence.Tests/Connections/ConnectionStoreTests.cs | Updates tests to create a real RustServer for FK-backed connection state. |
| tests/RustPlusBot.Persistence.Tests/Connections/ConnectionStateSchemaTests.cs | Adds cascade-delete test for ConnectionState when a server is removed. |
| tests/RustPlusBot.Features.Workspace.Tests/WorkspaceRegistrationTests.cs | Verifies IServerWorkspaceRemover resolves from DI. |
| tests/RustPlusBot.Features.Workspace.Tests/Messages/RendererTests.cs | Ensures new Disconnect/Remove buttons render in payloads. |
| tests/RustPlusBot.Features.Workspace.Tests/Hosting/WorkspaceConnectionStatusTests.cs | Adds hosted-service test for ServerCredentialsChangedEvent reconciliation. |
| tests/RustPlusBot.Features.Pairing.Tests/PairingSupervisorTests.cs | Adds tests for stopping a listener and idempotent stop behavior. |
| tests/RustPlusBot.Features.Pairing.Tests/PairingRegistrationTests.cs | Verifies IAccountDisconnectService resolves from DI. |
| tests/RustPlusBot.Features.Pairing.Tests/Fakes/FakePairingSource.cs | Tracks listener disposal for new stop-listener test. |
| tests/RustPlusBot.Features.Pairing.Tests/AccountDisconnectServiceTests.cs | Adds unit tests for disconnect + preview flows and event publishing. |
| tests/RustPlusBot.Features.Connections.Tests/ServerRemovalServiceTests.cs | Adds unit tests for ordered server removal orchestration. |
| tests/RustPlusBot.Features.Connections.Tests/RustPlusBot.Features.Connections.Tests.csproj | Adds Workspace reference for IServerWorkspaceRemover usage in tests. |
| tests/RustPlusBot.Features.Connections.Tests/ConnectionRegistrationTests.cs | Ensures IServerRemovalService resolves; stubs workspace remover. |
| tests/RustPlusBot.Features.Connections.Tests/ConnectionHostedServiceTests.cs | Adds hosted-service test for ServerCredentialsChangedEvent consumption. |
| src/RustPlusBot.Persistence/Migrations/BotDbContextModelSnapshot.cs | Updates EF snapshot for ConnectionState FK relationship metadata. |
| src/RustPlusBot.Persistence/Migrations/20260615183313_ServerRemovalCascade.Designer.cs | Adds generated model for the new cascade FK migration. |
| src/RustPlusBot.Persistence/Migrations/20260615183313_ServerRemovalCascade.cs | Adds migration to enforce FK/cascade from ConnectionStates to RustServers. |
| src/RustPlusBot.Persistence/Credentials/CredentialStore.cs | Implements owner-wide credential removal + server-id listing. |
| src/RustPlusBot.Persistence/Configurations/ConnectionStateConfiguration.cs | Configures one-to-one FK with cascade delete for connection state. |
| src/RustPlusBot.Features.Workspace/WorkspaceServiceCollectionExtensions.cs | Registers IServerWorkspaceRemover implementation in DI. |
| src/RustPlusBot.Features.Workspace/WorkspaceComponentIds.cs | Adds component IDs for disconnect/remove actions. |
| src/RustPlusBot.Features.Workspace/Teardown/WorkspaceTeardownService.cs | Implements new IServerWorkspaceRemover seam. |
| src/RustPlusBot.Features.Workspace/Teardown/IServerWorkspaceRemover.cs | Introduces public seam for workspace teardown per server. |
| src/RustPlusBot.Features.Workspace/Messages/SetupMessageRenderer.cs | Adds “Disconnect account” button to setup message UI. |
| src/RustPlusBot.Features.Workspace/Messages/ServerInfoMessageRenderer.cs | Adds “Remove server” button to server info UI. |
| src/RustPlusBot.Features.Workspace/Localization/LocalizationCatalog.cs | Adds EN/FR localized labels for new buttons. |
| src/RustPlusBot.Features.Workspace/Hosting/WorkspaceHostedService.cs | Adds consumer loop for ServerCredentialsChangedEvent. |
| src/RustPlusBot.Features.Pairing/Supervisor/PairingSupervisor.cs | Exposes StopListenerAsync(ulong, ulong) API. |
| src/RustPlusBot.Features.Pairing/Supervisor/IPairingSupervisor.cs | Adds StopListenerAsync contract. |
| src/RustPlusBot.Features.Pairing/RustPlusBot.Features.Pairing.csproj | Adds InternalsVisibleTo for DynamicProxyGenAssembly2 (tests/mocks). |
| src/RustPlusBot.Features.Pairing/PairingServiceCollectionExtensions.cs | Registers IAccountDisconnectService. |
| src/RustPlusBot.Features.Pairing/Modules/CredentialModule.cs | Adds disconnect prompt/confirm/cancel component handlers. |
| src/RustPlusBot.Features.Pairing/Accounts/IAccountDisconnectService.cs | Defines disconnect service contract + preview model. |
| src/RustPlusBot.Features.Pairing/Accounts/AccountDisconnectService.cs | Implements disconnect/preview and publishes server-credential change events. |
| src/RustPlusBot.Features.Connections/Removal/ServerRemovalService.cs | Implements ordered server removal orchestration. |
| src/RustPlusBot.Features.Connections/Removal/IServerRemovalService.cs | Adds server removal service contract. |
| src/RustPlusBot.Features.Connections/Modules/ServerRemovalModule.cs | Adds remove-server prompt/confirm/cancel interaction handlers. |
| src/RustPlusBot.Features.Connections/Hosting/ConnectionHostedService.cs | Adds consumer for ServerCredentialsChangedEvent. |
| src/RustPlusBot.Features.Connections/ConnectionServiceCollectionExtensions.cs | Registers IServerRemovalService in DI. |
| src/RustPlusBot.Abstractions/Events/ServerCredentialsChangedEvent.cs | Introduces new event to trigger failover + UI refresh. |
| src/RustPlusBot.Abstractions/Credentials/ICredentialStore.cs | Extends credential store abstraction with owner-wide removal/listing. |
Files not reviewed (1)
- src/RustPlusBot.Persistence/Migrations/20260615183313_ServerRemovalCascade.Designer.cs: Generated file
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| var owned = await context.PlayerCredentials | ||
| .Where(c => c.GuildId == guildId && c.OwnerUserId == ownerUserId) | ||
| .ToListAsync(cancellationToken) | ||
| .ConfigureAwait(false); | ||
| if (owned.Count == 0) | ||
| { | ||
| return []; | ||
| } | ||
|
|
||
| var serverIds = owned.Select(c => c.RustServerId).Distinct().ToList(); | ||
| context.PlayerCredentials.RemoveRange(owned); | ||
| await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); | ||
| return serverIds; |
There was a problem hiding this comment.
Done in 62b6f26. RemoveForOwnerAsync now projects the distinct RustServerIds first (no token columns materialised) and deletes set-based via ExecuteDeleteAsync, instead of loading full entities.
| services.AddScoped<IWorkspaceReconciler, WorkspaceReconciler>(); | ||
| services.AddScoped<IWorkspaceTeardownService, WorkspaceTeardownService>(); | ||
| services.AddScoped<IServerWorkspaceRemover, WorkspaceTeardownService>(); |
There was a problem hiding this comment.
Done in 62b6f26. WorkspaceTeardownService is now registered once as the scoped concrete type, and both IWorkspaceTeardownService and IServerWorkspaceRemover forward to it (sp => sp.GetRequiredService<WorkspaceTeardownService>()), so a scope resolves a single shared instance.
| protected override void Up(MigrationBuilder migrationBuilder) | ||
| { | ||
| migrationBuilder.AddForeignKey( | ||
| name: "FK_ConnectionStates_RustServers_RustServerId", | ||
| table: "ConnectionStates", | ||
| column: "RustServerId", | ||
| principalTable: "RustServers", | ||
| principalColumn: "Id", | ||
| onDelete: ReferentialAction.Cascade); | ||
| } |
There was a problem hiding this comment.
Addressed in 62b6f26 with a defensive DELETE FROM "ConnectionStates" WHERE "RustServerId" NOT IN (SELECT "Id" FROM "RustServers") before the FK is added.
In practice this is a no-op: ConnectionState shipped in the immediately-prior PR (#6) and no server-removal path existed before this PR (IServerService.RemoveAsync was never called), so an orphaned row could never have been created. The guard makes the migration robust regardless of DB state anyway.
| private async Task ConsumeRegisteredAsync(CancellationToken cancellationToken) | ||
| { | ||
| await foreach (var registered in eventBus.SubscribeAsync<ServerRegisteredEvent>(cancellationToken) | ||
| .ConfigureAwait(false)) | ||
| { | ||
| await supervisor.EnsureConnectionAsync(registered.GuildId, registered.ServerId, cancellationToken) | ||
| .ConfigureAwait(false); | ||
| } | ||
| } |
There was a problem hiding this comment.
Done in 62b6f26. Each consumer loop (ConsumeRegisteredAsync and ConsumeCredentialsChangedAsync) now has its own try/catch (OperationCanceledException + broad-catch -> log), matching the per-consumer isolation already used in WorkspaceHostedService. A fault in one consumer no longer propagates through Task.WhenAll or stops the other consumer.
| private async Task ConsumeCredentialsChangedAsync(CancellationToken cancellationToken) | ||
| { | ||
| await foreach (var changed in eventBus.SubscribeAsync<ServerCredentialsChangedEvent>(cancellationToken) | ||
| .ConfigureAwait(false)) | ||
| { | ||
| await supervisor.EnsureConnectionAsync(changed.GuildId, changed.ServerId, cancellationToken) | ||
| .ConfigureAwait(false); | ||
| } | ||
| } |
There was a problem hiding this comment.
Done in 62b6f26 (same change as the other consumer). Both ConsumeRegisteredAsync and ConsumeCredentialsChangedAsync are now independently wrapped, so either can fail and be logged without taking down the rest of the hosted service.
…, DI forwarding, migration orphan guard)
Summary
Subsystem 1b-iii completes the connection lifecycle with two destructive user flows plus the missing cascade:
#info(two-step confirm) →ServerRemovalServiceorchestrates, in order, stop the live socket → delete theRustServer(DB cascade removes its credentials + connection-state) → tear down the Discord category (new publicIServerWorkspaceRemoverseam). Ordering is load-bearing: stopping first prevents a late status write from re-inserting aConnectionStateagainst a deleted server.#setup(confirm lists affected servers) →AccountDisconnectServicestops the user's FCM listener, marks theirFcmRegistrationDisabled, removes all their pool credentials guild-wide, and publishes aServerCredentialsChangedEventper affected server. A server emptied this way goesNoCredentials— never auto-removed.ConnectionState → RustServerFK withOnDelete(Cascade)(migrationServerRemovalCascade), mirroring the existingPlayerCredentialcascade — no orphaned status rows.ServerCredentialsChangedEvent(Abstractions) is consumed by Connections (EnsureConnectionAsync→ fails over to the next Standby, orNoCredentials) and Workspace (ReconcileServerAsync→ refreshes the#infoembed/select). Failover works through the existingGetActiveCredentialAsync(selects byStatus == Active), so removing the active credential auto-promotes a Standby with no store change.Button labels are localized EN/FR; ephemeral confirm/result text stays English (matches every existing module — a conscious 1b-iii decision).
Deferred to a future 1b-iv (per the spec): IP-change re-identification (no stable server id in the FCM notification) and live auth-reject verification (needs a live Rust server to observe the error shape).
Test Plan
dotnet build RustPlusBot.slnx— 0 errors (2 pre-existing, CI-skippedConfigureGitHooksMSB3073 warnings, unrelated)dotnet test RustPlusBot.slnx— 121/121 pass (Abstractions 4, Workspace 36, Pairing 26, Connections 14, Persistence 41)has-pending-model-changesreports no changes)dotnet jb cleanupcode RustPlusBot.slnx --profile=ReformatAndReorder— applied (CI format gate)RemoveForOwnerAsync/ListServerIdsForOwnerAsync(incl. cross-guild isolation);StopListenerAsync;AccountDisconnectService(happy/no-op/preview);ServerRemovalServiceorder + absent-server; both hosted-service event consumers; both rendered buttons. Interaction modules remain thin (untested by repo convention).Minor follow-ups noted in review (non-blocking)
ConnectionHostedServiceruns both consumers under oneTask.WhenAll/try-catch (shared-fate) vs. Workspace's per-consumer try-catch — asymmetric resilience; low risk.IServerWorkspaceRemoveris a secondAddScopedregistration (two instances/scope); a forwarding registration would be tidier.🤖 Generated with Claude Code